Skip to content

feat!: v3 — chain_id in signing, bigint amounts, unsigned-tx verification, Burn - #4

Merged
MehranMazhar merged 2 commits into
mainfrom
treasury-break
Jul 30, 2026
Merged

feat!: v3 — chain_id in signing, bigint amounts, unsigned-tx verification, Burn#4
MehranMazhar merged 2 commits into
mainfrom
treasury-break

Conversation

@MehranMazhar

Copy link
Copy Markdown
Member

Companion to clutchprotocol/clutch-node#9 and clutchprotocol/clutch-hub-api#3. Merge last — merging this to main publishes v3.0.0 to npm via semantic-release, so the node and hub should be in place first.

Wire format

The node moved its hash preimage to the 4-item RLP list [from (no 0x), nonce, chain_id, data] and its signed payload to the 8-item list [from, nonce, chain_id, r, s, v, hash, data], chain_id at index 2.

chain_id is encoded as minimal big-endian to match Rust's u64 append — 2077 is 82 08 1d, byte-verified in the self-check rather than merely round-tripped, because a round-trip would pass even if both sides were wrong together.

Amounts become bigint

fare, farePaid, amount and balances are bigint; GraphQL variables are String.

JSON.parse silently rounds integers above 2^53. At the peg of 1 USD = 1,000,000 CLT a number fare was already lossy before signing — the user would sign one amount and believe they had approved another. GraphQL's Int compounded it by being 32-bit, overflowing at roughly a $2,147 fare.

rlp accepts bigint directly, so values pass through unconverted — RLP-encoding a decimal string would encode its UTF-8 text rather than the integer.

Also adds formatUsd(microUsd) (integer math, no floats) so UIs can show $5.00 instead of 5000000.

The security fix: stop signing blindly

signTransaction previously signed whatever blob the hub returned, checking nothing. The hub is the untrusted party in this design — the key never leaves the client because the hub isn't trusted — yet it could alter the fare, swap the referrer, or hand back another chain's id, and this SDK would sign it. Client-side signing protected the key but not the user.

verifyUnsignedTransaction(unsignedTx, expected) now checks from, function_call_type, fare/amount (BigInt-parsed), tx-hash references, and the chain id — throwing before anything is signed.

Two details that matter more than they look:

  • The chain id is pinned client-side, from app config — never from the hub's chainInfo. Asking the untrusted party which chain it is defeats the check entirely.
  • Verification fails closed with no pinned chain. An earlier revision skipped the chain check when no chainId was configured, reasoning that nothing had been pinned. That quietly recreated the exact hole chain_id was added to close: every check runs except the one that stops a cross-chain replay, while the caller believes the transaction was validated. It now throws. (Nearly unreachable anyway, since ensureAuth needs the real chain id for the chain-bound challenge — so this mostly turns a confusing downstream auth failure into a precise message.)

The hub-injected referrer cannot be verified without the signed-quote flow a later plan adds, so verification returns it for the UI to display before signing. That display is the interim mitigation, not a fix.

Also

  • Burn (RLP tag 7) with its optional redemption_ref, empty-string convention included, plus createUnsignedBurn.
  • Auth challenge is chain-bound: clutch-auth:{chainId}:{publicKey}:{timestamp}, matched byte-for-byte against the hub's committed Rust fixtures. Previously a challenge signed on testnet authenticated the same key against any other Clutch hub inside the ±120s window.
  • dist is loadable under Node ESM again — relative imports gained .js extensions. tsc emits extensionless specifiers and there is no "type": "module", so import './dist/index.js' failed with ERR_MODULE_NOT_FOUND; the repo's own ad-hoc check scripts had rotted for this reason.

Verification

npm run build && node test_wire_v3.mjswire v3 self-check OK. It asserts the 8-item layout, the chain_id bytes, Burn tag 7 with and without a ref, a tampered fare throwing, a mismatched chain id throwing, fail-closed on an unpinned chain, and formatUsd cases.

Not covered here: a live cross-check against a running node/hub, which needs a funded faucet on a running stack — that happens in the full-stack smoke. The auth fixtures are a real cross-language match against the hub's Rust, which is the strongest check available without the stack.

Known, deliberately not changed

dist still emits a MODULE_TYPELESS_PACKAGE_JSON warning. Adding "type": "module" would change module resolution for every consumer of a published package (breaking CJS require), which deserves its own decision rather than riding along here.

🤖 Generated with Claude Code

MehranMazhar and others added 2 commits July 29, 2026 02:26
…ication, Burn

- signTransaction now RLP-encodes chain_id at index 2 in both the hash
  preimage (4-item list) and the full signed payload (8-item list),
  matching clutch-node's Plan A wire format byte-for-byte.
- Add verifyUnsignedTransaction(unsignedTx, expected): pure, exported.
  signTransaction calls it when given `expected`, closing the
  blind-signing hole where a compromised hub could alter the fare, swap
  the chain_id, or hand back a mismatched tx type. chain_id is checked
  with strict equality (not presence-only) against a client-pinned
  value, never the hub's own chainInfo response. The hub-injected
  referrer cannot yet be verified (needs a future signed-quote flow) so
  it is returned in VerifiedTx.referrer for the caller to display
  pre-sign as an interim mitigation.
- Add Burn (RLP tag 7) support: encodeFunctionCall case, and
  createUnsignedBurn mutation wrapper.
- Auth challenge is now chain-bound: clutch-auth:{chainId}:{publicKey}:
  {timestamp}. Verified byte-for-byte against clutch-hub-api's own
  Rust test fixtures in auth.rs.
- Add getAuthHeaders() for callers that need this SDK's JWT outside
  its own GraphQL calls.
- Add formatUsd(microUsd: bigint): string for integer-only $X.XX
  display.
- Add explicit .js extensions to relative imports in src/index.ts and
  src/sdk.ts so dist/ is loadable under Node ESM (tcs emits
  extensionless specifiers otherwise, which ERR_MODULE_NOT_FOUNDs
  without bundler resolution).

BREAKING CHANGE: signTransaction's hash preimage and signed payload
both gained chain_id (inserted after nonce; everything after it shifts
by one index). fare/amount/balance public types moved from number to
bigint; the corresponding GraphQL mutation variables changed from
Int to String. buildAuthChallengeMessage/authChallengeHashHex/
signAuthChallenge gained a required leading chainId parameter and the
auth challenge string format changed — no fallback to the old
two-field format. Requires clutch-node treasury-break and a hub-api
build with chainInfo/createUnsignedBurn. The orchestrator REST client
described in the task brief was deliberately not built: it targets a
payment-orchestrator service that does not exist yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
signTransaction(.., expected) previously ran every check EXCEPT the
chain_id pin when no chainId was configured, on the reasoning that
nothing had been pinned. That is the worst available outcome: the caller
believes the transaction was validated while the one check that stops a
cross-chain replay quietly did not run - the same hole chain_id was added
to close. It now throws, naming the constructor argument.

Nearly unreachable in practice, since ensureAuth already needs the real
chainId for the chain-bound challenge, so an unconfigured SDK cannot get
a token at all; this converts that confusing downstream auth failure into
a precise message. The self-check now pins the behaviour, and its own SDK
construction was the first caller the change caught.

BREAKING CHANGE: verifying an unsigned transaction now requires a chainId
pinned via the ClutchHubSdk constructor or expected.chainId.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@MehranMazhar

Copy link
Copy Markdown
Member Author

🎉 This PR is included in version 3.0.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant